Day 10 處理完「規則到底該由誰負責」之後,我原本以為流水號改版已經進入收尾。
新建資料會走新的流程,四碼十進位的規則也已經明確下來。照理說,接下來只要確認新資料正常新增就好。
結果真正出問題的,反而不是新資料,而是一筆舊資料。
資料庫裡原本就存在一些舊制流水號,例如:
001
029
0AF
新制則要求新建立的資料使用四碼十進位:
0001
0002
0003
...
0648
某天使用者打開一筆舊資料:
ProjectNo = P001
ItemNo = 029
Remark = 舊備註
他完全沒有修改 ItemNo,只是把備註改成:
補充 2026 年維護說明
按下儲存後,系統卻跳出:
流水號必須為四碼十進位。
這就很尷尬了。
029 的確不符合 2026 年的新建規則,但它是在舊制度下合法建立、而且已經存在多年的資料。使用者現在也根本沒有修改流水號。
真正的問題不是「舊資料格式錯了」,而是我把一條只應該約束新資料的規則,套到了所有年代的 UPDATE 上。
Day 10 問的是:
Who owns the rule?這條規則由誰負責?
到了 Day 11,還要再多問一題:
When does the rule apply?這條規則到底從什麼時候、對什麼操作開始生效?
同一條規則除了要知道誰負責,還要知道什麼時候適用。
這次最容易混淆的是兩句看起來很像的規則:
2026 年之後新增的流水號,必須是四碼十進位。
以及:
資料庫裡所有資料只要發生 UPDATE,流水號都必須是四碼十進位。
兩句只差幾個字,商業語意卻完全不同。
假設 029 在舊制度下原本就是合法資料,那麼今天使用者只修改 Remark 時,真正需要判斷的是「這個備註能不能修改」,而不是重新問:
如果今天要重新新增
029,它合不合法?
答案當然是不合法。
但這並不代表昨天合法存在的 029,今天就突然變成一筆不能修改的資料。
這也是 Legacy System 很常見的相容性問題:
Valid when created,不等於 Valid for new creation today。
相容性不是把舊資料全部放行,而是要精確區分:
先看一個匿名化後的教學版本:
CREATE TABLE dbo.DocumentHeader
(
DocumentId int IDENTITY(1,1) NOT NULL
CONSTRAINT PK_DocumentHeader PRIMARY KEY,
ProjectNo varchar(20) NOT NULL,
ItemNo varchar(4) NOT NULL,
Remark nvarchar(200) NULL
);
-- 永久成立的不變量:
-- 同一案件不能出現相同流水號。
CREATE UNIQUE INDEX UX_DocumentHeader_ProjectNo_ItemNo
ON dbo.DocumentHeader(ProjectNo, ItemNo);
這裡保證的是資料庫字串值的唯一性;新舊格式是否具有相同業務語意,仍需另外確認。
假設在新規則上線前,資料庫裡早就存在:
INSERT INTO dbo.DocumentHeader
(
ProjectNo,
ItemNo,
Remark
)
VALUES
(
'P001',
'029',
N'十年前建立的資料'
);
後來為了保護四碼新制,新增了一個很直覺的 Trigger:
CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_ItemNoRule
ON dbo.DocumentHeader
AFTER INSERT, UPDATE
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS
(
SELECT 1
FROM inserted
WHERE LEN(ItemNo) <> 4
OR ItemNo LIKE '%[^0-9]%'
)
BEGIN
THROW 51001, N'流水號必須為四碼十進位。', 1;
END;
END;
問題就在 AFTER INSERT, UPDATE。
當我執行:
UPDATE dbo.DocumentHeader
SET Remark = N'補充 2026 年維護說明'
WHERE DocumentId = 1;
即使 ItemNo 完全沒變,這筆資料仍然會出現在 inserted。
Trigger 接著看到:
ItemNo = 029
再拿「四碼十進位」重新檢查一次,最後把整次 UPDATE 擋掉。
所以真正的 Bug 並不是:
029是錯的。
而是:
我把「2026 年新增資料必須使用四碼」寫成了「任何年代的資料每次 UPDATE 都必須符合四碼」。
另一個極端也不行。
如果只是為了讓 029 可以修改備註,就把所有 UPDATE 驗證拿掉,那麼使用者可能連流水號本身都能直接改掉。
例如:
029 → 030
029 → ABC
這就不是「保留歷史資料」,而是改變歷史資料的識別值了。
因此 Update 流程不能只問「這是不是舊資料」,而要再問:
這一次到底改了什麼?
SQL Server 在 UPDATE 時會提供:
deleted:更新前的資料。inserted:更新後的資料。所以如果要判斷 ItemNo 是否真的改變,應該比較前後值,而不是只看到 UPDATE 就重新套一次 Create Rule。
也不建議只靠:
IF UPDATE(ItemNo)
因為它比較接近「這個欄位有沒有出現在 UPDATE 動作中」,不等於「欄位值最後真的改變」。
Legacy System 裡,這個差異很重要。
這次我最後採用的方向,不是讓 Trigger 變得更聰明,而是先把 Create 與 Update 的語意拆開。
新建資料繼續嚴格遵守四碼新制:
CREATE OR ALTER PROCEDURE dbo.CreateDocument
@ProjectNo varchar(20),
@Remark nvarchar(200)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ItemNo varchar(4);
-- 正式產號邏輯沿用前幾天處理過的流程。
-- 這裡只示意新建資料最後必須得到四碼十進位。
SET @ItemNo = '0648';
IF LEN(@ItemNo) <> 4
OR @ItemNo LIKE '%[^0-9]%'
BEGIN
THROW 51001, N'新文件流水號必須為四碼十進位。', 1;
END;
INSERT INTO dbo.DocumentHeader
(
ProjectNo,
ItemNo,
Remark
)
VALUES
(
@ProjectNo,
@ItemNo,
@Remark
);
END;
修改既有資料時,則不要把 ItemNo 帶進一般的備註修改流程:
CREATE OR ALTER PROCEDURE dbo.UpdateDocumentRemark
@DocumentId int,
@Remark nvarchar(200)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.DocumentHeader
SET Remark = @Remark
WHERE DocumentId = @DocumentId;
END;
這個設計看起來很普通,但它其實把 Use Case 的能力限制得很清楚:
不是把 ItemNo 傳進來,再祈禱程式不要亂改;而是修改備註這個 Use Case 根本沒有修改 ItemNo 的能力。
資料庫仍然可能有舊 Client、批次程式,甚至直接 SQL 繞過 Stored Procedure。
因此如果 Trigger 要留下,我不再讓它對所有 UPDATE 重跑「四碼新建規則」,而是分成兩件事:
ItemNo,一般流程直接拒絕。CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_ItemNoRule
ON dbo.DocumentHeader
AFTER INSERT, UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- 新增資料:此處只示意四碼十進位的「格式檢查」;
-- 實際流水號範圍仍由正式 Create Rule 負責。
IF EXISTS
(
SELECT 1
FROM inserted AS i
LEFT JOIN deleted AS d
ON d.DocumentId = i.DocumentId
WHERE d.DocumentId IS NULL
AND
(
LEN(i.ItemNo) <> 4
OR i.ItemNo LIKE '%[^0-9]%'
)
)
BEGIN
THROW 51001, N'新文件流水號必須為四碼十進位。', 1;
END;
-- 更新資料:只有 ItemNo 前後真的不同才攔截。
IF EXISTS
(
SELECT 1
FROM inserted AS i
INNER JOIN deleted AS d
ON d.DocumentId = i.DocumentId
WHERE i.ItemNo <> d.ItemNo
)
BEGIN
THROW 51002, N'既有文件不得透過一般修改流程變更流水號。', 1;
END;
END;
這樣一來:
舊資料 029,只改 Remark
→ inserted.ItemNo = 029
→ deleted.ItemNo = 029
→ ItemNo 沒有變
→ 允許
但如果是:
029 → 030
前後值不同,就會被擋下。
而新的:
029
如果企圖在新規則上線後直接 INSERT,也會被第一段檢查拒絕。
這才是我這次真正想要的相容性:
允許歷史存在,但不讓舊格式繼續被製造,也不讓一般流程任意改寫歷史識別值。
不是每一套 Legacy System 都適合只靠 Create/Update 分流。
以下 RuleVersion 是另一種可選設計,不是前面主方案必須接續部署的步驟;如果採用這個方案,正式 Create 流程也必須固定寫入 RuleVersion = 2。
如果資料年代無法從格式可靠判斷,也可能需要額外加入 RuleVersion。但這個欄位不能永遠保持 Nullable,也不能變成讓新資料假裝自己是 Legacy 的逃生門。
比較安全的導入順序會是:
-- 1. 先允許 NULL,避免一加欄位就讓既有資料失敗。
ALTER TABLE dbo.DocumentHeader
ADD RuleVersion tinyint NULL;
-- 2. 先切換正式 Create 路徑:
-- 所有新建資料固定寫入 RuleVersion = 2。
-- 3. 確認新的寫入路徑已不再產生 RuleVersion = NULL。
-- 4. 再 Backfill 當下剩餘的 NULL,視為既有 Legacy Data。
UPDATE dbo.DocumentHeader
SET RuleVersion = 1
WHERE RuleVersion IS NULL;
-- 5. 驗證資料中已經沒有 RuleVersion = NULL。
IF EXISTS
(
SELECT 1
FROM dbo.DocumentHeader
WHERE RuleVersion IS NULL
)
BEGIN
THROW 51003, N'RuleVersion 尚有未分類資料,暫停收斂。', 1;
END;
-- 6. 完成分類後,再收斂成 NOT NULL。
ALTER TABLE dbo.DocumentHeader
ALTER COLUMN RuleVersion tinyint NOT NULL;
-- 7. 最後才建立版本規則。
ALTER TABLE dbo.DocumentHeader
ADD CONSTRAINT CK_DocumentHeader_ItemNoByVersion
CHECK
(
RuleVersion = 1
OR
(
RuleVersion = 2
AND LEN(ItemNo) = 4
AND ItemNo NOT LIKE '%[^0-9]%'
)
);
這個順序的關鍵是:先堵住新的 NULL,再清掉歷史 NULL。 這樣 Backfill 執行時,才不會把切換期間剛新增、但尚未寫入版本值的資料誤標成 Legacy。換句話說,連 Migration 本身也有時間邊界。
最後仍要收斂成 NOT NULL,因為 CHECK Constraint 對 NULL 所形成的 UNKNOWN 判斷可能不會像直覺上那樣把資料拒絕掉。
而且即使加了 RuleVersion,一般新建入口也不能自由指定:
RuleVersion = 1
UI、API、Stored Procedure,甚至 Direct SQL 的權限都要一起考慮;否則版本欄位反而會變成繞過新規則的後門。
CHECK Constraint 能驗證一筆資料現在的狀態,卻不能證明 RuleVersion = 1 真的是十年前留下來的歷史資料。
另一種做法是 Soft-accept:
Read old, write new.
舊資料照原樣讀取與修改非識別欄位,但所有新資料只寫新格式。
如果未來確認舊編號沒有外部引用、也能建立可靠的對照關係,才考慮做 Migration。這些都是可能的策略,但在這次案例裡,我不需要一次把問題擴大成全面資料搬遷。
這次修完後,我不只測:
029現在可以改備註了嗎?
因為最危險的修法,就是救回舊資料的同時,也讓新系統重新可以建立三碼資料。
我最後至少會驗證這些情境:
| 測試情境 | 操作 | 預期結果 |
|---|---|---|
| 新制正常新增 | 走正式 Create 流程 | 成功,得到四碼十進位 |
新資料嘗試建立 029 |
直接 INSERT 或繞過 UI | 拒絕 |
新資料嘗試建立 0AF |
直接 INSERT 或繞過 UI | 拒絕 |
舊資料 029 只改 Remark |
不碰 ItemNo | 成功,ItemNo 保持 029 |
舊資料 0AF 只改 Remark |
不碰 ItemNo | 若原本為合法歷史資料,應成功 |
| 舊資料直接修改 ItemNo | 029 → 030 |
一般流程拒絕 |
| 同案件重複 ItemNo | 建立重複鍵值 | Unique Index 拒絕 |
| 批次修改 Remark | 一次 UPDATE 多筆 | 全部依集合邏輯正確處理 |
| 批次中包含非法改號 | 同一個 UPDATE 涵蓋多筆,其中至少一筆 ItemNo 被修改 | 整個 Statement 被拒絕,不留下部分成功 |
其中 Legacy 測試資料的準備方式也要特別注意。
下面這種 fixture:
DECLARE @LegacyId int;
INSERT INTO dbo.DocumentHeader
(
ProjectNo,
ItemNo,
Remark
)
VALUES
(
'TEST-P001',
'029',
N'Legacy'
);
SET @LegacyId = SCOPE_IDENTITY();
只能用來模擬「新規則啟用以前就已經存在的資料」。
實際測試時,這筆資料應該在新規則部署前建立,或由隔離的測試資料初始化腳本準備;不能在 Production 新規則已啟用後,再透過正式 Create 流程建立 029。
因為連測試資料怎麼被建立,本身都有時間語意。
準備好 Legacy fixture 並部署新規則後,再測:
UPDATE dbo.DocumentHeader
SET Remark = N'Legacy remark updated'
WHERE DocumentId = @LegacyId;
SELECT
DocumentId,
ItemNo,
Remark
FROM dbo.DocumentHeader
WHERE DocumentId = @LegacyId;
預期結果:
ItemNo = 029
Remark = Legacy remark updated
接著再驗證非法改號:
UPDATE dbo.DocumentHeader
SET ItemNo = '030'
WHERE DocumentId = @LegacyId;
這一步應該失敗。
最後再確認新資料不能重新製造舊格式:
INSERT INTO dbo.DocumentHeader
(
ProjectNo,
ItemNo,
Remark
)
VALUES
(
'TEST-P002',
'029',
N'New legacy-shaped data'
);
這一步也應該失敗。
這一段我反而不會請 AI 直接重寫 Trigger。
我比較需要它幫我找出「我可能漏測了什麼」。
以下是 Legacy ERP 從三碼舊制改為四碼新制後的 SQL / Delphi 修改。
我要驗證的不是「程式能編譯」,而是 backward compatibility。
已知規則:
1. 新資料只能建立四碼十進位 ItemNo。
2. 舊三碼或舊混碼資料可能仍合法存在。
3. 修改 Remark 等非 ItemNo 欄位時,不應重新套用新建格式規則。
4. ItemNo 若真的改變,必須進入專用流程或拒絕。
5. Trigger 必須支援 multi-row INSERT / UPDATE。
請建立 Regression Test Matrix,涵蓋:
- 新資料正常 INSERT
- 新資料三碼或混碼應被拒絕
- 舊資料只修改非 ItemNo 欄位
- 舊資料嘗試修改 ItemNo
- 同案件重複 ItemNo
- Multi-row UPDATE
- Multi-row UPDATE 中只有部分資料修改 ItemNo,確認整個 Statement 的結果
- Direct SQL bypass UI
- 舊 Client 與新版 Client 共存
- Rollback
每個案例請列出:
Precondition、Action、Expected database result、Expected UI result、
Possible false positive、Rollback step。
不要只測 Happy Path,也不要先假設目前修改已經正確。
AI 在這裡最有價值的地方,不是替我宣布「修好了」。
而是幫我多想幾種方式,證明它可能還沒好。
這次最後並不是靠一條更複雜的四碼驗證解決問題。
真正需要修改的,是我對「合法資料」的理解。
029 如果是在舊制度下合法建立的資料,那麼今天使用者只修改備註時,我不能因為新建規則改成四碼,就突然把它判成非法資料。
但這也不代表新系統可以繼續建立 029。
Day 10 處理的是「這條規則由誰負責」;Day 11 則往前多追了一步:
這條規則到底從什麼時候開始有權管資料?
這也是維護 Legacy System 很容易被忽略的一層。
AI 可以幫我搜尋所有 Trigger、Stored Procedure、Delphi 事件與 SQL 條件,也可以幫我整理哪些地方在 INSERT、哪些地方在 UPDATE。
但它不會憑空知道 029 在十年前是不是一筆合法建立、已經核准,甚至早就被其他系統引用的正式資料。
這個邊界,最後還是必須由維護工程師確認。
💡 今日金句:新規則的責任,不是改寫歷史,而是確保從今天開始不要再製造新的歷史問題。